Skip to content

feat(billing): take Google Play purchases and notifications - #282

Merged
aquie00t merged 1 commit into
mainfrom
feature/play-billing-adapter
Sep 6, 2026
Merged

feat(billing): take Google Play purchases and notifications#282
aquie00t merged 1 commit into
mainfrom
feature/play-billing-adapter

Conversation

@aquie00t

@aquie00t aquie00t commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Part of #276. This is the half of the Play adapter that does not need Google — the endpoints, the notification envelope, the state mapping and the replay guard. GooglePlayBillingService itself needs a Play Console that exists, so the issue stays open.

Summary

POST /billing/play/purchases — authenticated. The app hands over the purchaseToken the billing library produced, right after Play reports a successful purchase.

This call is authenticated for a specific reason: it is the only place the link between a purchase and an account is ever learned. Google's notifications name a token and a product and nothing else. It trusts the client for nothing else — the token goes straight to Google for verification and what comes back is what gets stored, so a fabricated token buys a row that says PENDING and no badge. A purchase Google cannot confirm right now is still linked, as PENDING, because without the row nothing would ever connect that token to that account again; the nightly reconcile finishes it.

POST /billing/play/notifications — where Pub/Sub pushes. No session, guarded by a shared secret on the URL (PLAY_NOTIFICATIONS_TOKEN). Empty means closed, which is the right default for an unauthenticated route that writes billing state.

The notification is treated as a nudge, never as state: it says a purchase changed, and what it changed to is then read from the Play Developer API. Deliveries are unordered and redelivered, so believing their contents would mean reinstating subscriptions that have ended. Every delivery is recorded in billing_events first, which spots a redelivery and leaves the trail that is the first thing anybody wants when a subscription is in the wrong state.

It answers 204 for everything it understood — including a redelivery, and a purchase no account claims yet, which happens legitimately when Google pushes before the app's own call arrives. Pub/Sub retries every non-2xx, and retrying either of those achieves nothing. A genuine failure escapes as a 5xx, which is exactly the answer that makes Google try again.

mapPlayState is the single place Google's vocabulary becomes ours. ACTIVE and IN_GRACE_PERIOD entitle; PAUSED, ON_HOLD, CANCELED and EXPIRED do not, since none of them are being paid for. A state we do not recognise reads as not entitling — Google adds values over time, and the safe reading of "I do not know this" is that the badge is off.

What is deliberately not here

GooglePlayBillingService — the BillingPort implementation that calls the Play Developer API to verify and to cancel. It needs the Play Console side: a subscription product, a service account with "View financial data" and "Manage orders and subscriptions", and the Pub/Sub topic. Until it exists NoopBillingService stands in, so purchases link as PENDING and no badge is granted anywhere.

Verifying the OIDC token Google can attach to a push is the stronger alternative to the shared secret and belongs with that same work; the secret is a real guard in the meantime, not a placeholder.

The purchase endpoint should also carry idempotency: true once #281 is on main — it is left off here only because the flag's type lives on that branch. A retried hand-over is already harmless: the sync writes absolute state onto one row per account.

Tests

  • Unit, 1518 passing (18 new): envelope parsing (a real notification, an undocumented type number, the console's test notification, six unusable bodies that must be ignored rather than throw, and the fallback dedupe key when Pub/Sub sends no id); mapPlayState across entitling, non-entitling and unknown states; PlayNotificationService asking Google rather than believing the notification, doing nothing twice on a redelivery, refusing to invent an account for an unclaimed purchase, leaving the row alone when Google cannot say, and recording the delivery before acting; RegisterPlayPurchaseUseCase verifying before granting, storing what the provider says rather than what the client sent, and linking an unconfirmable purchase as pending.
  • E2E tests/e2e/billing/play.test.ts: an unconfirmable purchase links without a badge and shows up as PENDING on GET /billing/subscription; the endpoint needs a session; an empty token is rejected; the notification endpoint refuses both a missing and a wrong secret.
  • tsc -p tsconfig.build.json --noEmit, eslint, prettier --check clean.

One new setting, PLAY_NOTIFICATIONS_TOKEN, empty by default — so this merges and deploys with the notification endpoint closed and nothing else changed.

AI Asistan: Opus 5

The half of the Play adapter that does not need Google: the two endpoints,
the notification envelope, the state mapping and the replay guard. What is
missing is GooglePlayBillingService itself, which needs a Play Console that
exists - until then NoopBillingService stands in and nothing is granted.

POST /billing/play/purchases is authenticated because it is the only place
the link between a purchase and an account is ever learned: Google's
notifications name a token and a product and nothing else. It trusts the
client for nothing - the token is handed to Google and what comes back is
stored - so a purchase nobody can confirm links as PENDING with no badge,
which is what lets the nightly reconcile finish it later.

POST /billing/play/notifications takes the Pub/Sub push behind a shared
secret on the URL; empty keeps it closed, which is the right default for an
unauthenticated route that writes billing state. The notification is a nudge
and never state - deliveries are unordered and redelivered, so what changed
is read back from Google. It answers 204 for anything it understood,
including a redelivery and a purchase no account claims, because Pub/Sub
retries every non-2xx and retrying those achieves nothing.

An unrecognised subscription state reads as not entitling. Google adds values
over time and the safe reading of "I do not know this" is that the badge is
off, not on.
@aquie00t
aquie00t merged commit a4975fc into main Sep 6, 2026
10 checks passed
@aquie00t
aquie00t deleted the feature/play-billing-adapter branch September 6, 2026 09:32
github-actions Bot pushed a commit that referenced this pull request Sep 6, 2026
# [1.27.0](v1.26.0...v1.27.0) (2026-09-06)

### Features

* **billing:** take Google Play purchases and notifications ([#282](#282)) ([a4975fc](a4975fc))
* **http:** make a retried write safe to send twice ([#281](#281)) ([43c0c97](43c0c97))
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.27.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant